就像偵探辦案不會把嫌疑名單上每一個人都問一輪口供,而是先靠案發現場的蛛絲馬跡鎖定範圍再仔細盤問——
/ask-vault也不通讀全庫正文,先靠標題與標籤篩出候選筆記,再只對候選筆記做仔細的語意理解,過程中也修正了兩個規格與實際行為對不上的地方。
brain-cli 現有的 scan/health/graph 都只能做結構化查詢:列出筆記清單、健康度統計、Wikilink 圖的節點與邊。這些指令回答不了「我之前寫過哪些關於 Cobra 的筆記?」這種自然語言問題——它們不理解語意,只理解欄位。但反過來,若每次提問都請 Agent 把 vault 裡所有筆記的正文讀過一遍才回答,vault 筆記量一旦成長,這個做法既慢又貴,而且大部分被讀到的筆記其實跟問題無關。今天要定案的 /ask-vault,就是在這兩個極端之間找一條路:先用 brain-cli 既有的結構化索引快速篩出候選筆記,再由 Claude Code 只讀候選筆記的正文做語意理解。
/ask-vault <自然語言問題> 的核心流程分兩段:
brain scan --json 取得全庫筆記的 id/title/type/status/path,用問題關鍵字比對 title 與 Frontmatter tags 篩出候選清單,這一階段完全不讀正文。這個切分延續 Day18(agent-tool-bridge)「Agent 透過既有唯讀 --json 指令取得結構化資料」的模式,也延續 Day15-17「斜線指令定義檔 + Agent 依步驟直接執行,不新增 Go 子指令」的做法——/ask-vault 完全建立在既有的 brain scan/brain graph 之上,沒有新增、修改任何 brain-cli 的子指令、旗標或輸出 schema。
scan --json 沒有 tags規格草稿原本寫「依 scan --json 的 title/tags 欄位比對」,寫完 ask-vault.md 準備實際驗證時才發現:翻開 cmd/brain/main.go 的 scanJSONNote 結構,欄位只有 id/title/type/status/path,tags 從來沒有輸出過。
type scanJSONNote struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Status string `json:"status"`
Path string `json:"path"`
}
Non-Goal 明確寫著「不新增、不修改任何 brain-cli Go 子指令、旗標或輸出 schema」,所以解法不是回頭幫 scan --json 加欄位,而是換一個不違反 Non-Goal 的路徑:scan --json 已經給了每篇筆記的 path,Agent 可以自己依 path 讀取檔案開頭以 --- 分隔的 YAML Frontmatter 區塊取得 tags——只讀到第二個 --- 為止,不讀之後的正文。這仍然算「結構檢索」,因為 Frontmatter 跟正文本來就是用 --- 明確分隔的兩塊資料,讀 Frontmatter 不等於讀正文全文。發現這個落差後回頭同步修正了 design.md 與 specs/qa-retrieval/spec.md 的用字,避免規格文件跟實際行為對不上。
如果關鍵字在 title/tags 都比對不到,直接回報「找不到相關筆記」太快放棄——brain graph --json 手上還有一份 Wikilink 的圖,值得再試一次。第一版寫法是:「關鍵字對不到任何節點的 label 時,就拿全庫所有節點當起點去擴散鄰居」。這句話乍看合理,實測後發現是個陷阱:只要圖裡還有任何一條邊,這個規則永遠會湊出同一批「有邊的節點」當候選,不管問題內容是什麼——因為「全庫所有節點的鄰居聯集」本來就是圖裡所有非孤立節點,跟關鍵字完全無關。這樣一來,規格要求的「擴散一層後仍找不到候選」這個分支永遠不會發生,除非整張圖沒有任何一條邊,等於這條防線形同虛設。
修正後的規則是:擴散前一定要先找到「種子節點」——關鍵字要嘛寬鬆比對命中某個節點的 label,要嘛命中該筆記 Frontmatter 裡「第一階段沒用到」的 aliases 欄位(讀取範圍一樣只到 Frontmatter,不算讀正文)。兩者都比對不到,就直接判定擴散無以為繼,回報找不到相關筆記,不會退而求其次拿全庫節點硬湊。有種子節點才收集它在 edges 裡的直接鄰居當新候選。這樣「擴散後仍為零」才是一個真的會發生、而不是理論上存在的分支。
候選數量的另一端問題是「太多」。這裡刻意不寫死一個絕對數字上限(例如「最多 5 篇」),而是要求 Agent 依「問題關鍵字命中 title/tags 的次數」排序後,自行判斷讀取正文的合理範圍——但必須在最終回答裡告訴使用者「本次已限縮候選範圍,只根據其中 N 篇筆記作答」,並附上限縮前的候選總數。理由是 vault 規模差異很大,寫死數字要嘛在小 vault 上過度限縮、要嘛在大 vault 上仍然太多;真正該規範的是「限縮這件事必須對使用者透明」,而不是規定死板的門檻。
對 obsidian-agent-brain 的 demo vault(14 篇筆記、14 條 Wikilink 邊,跟 Day21 定案的圖一致)實際跑三個情境:
情境一:關鍵字直接命中 title ——問「vault 裡有哪些跟 Cobra 這個 CLI 框架相關的筆記?」,關鍵字「Cobra」直接命中 4 篇筆記的 title(《Cobra CLI 框架》《選用 Cobra 作為 CLI 框架》《Cobra flag 綁定筆記》《Cobra 子指令樹筆記》),第一階段完全沒讀正文。第二階段讀完這 4 篇正文後,答案能具體說出《選用 Cobra 作為 CLI 框架》是一篇 ADR、記錄了選用 spf13/cobra 而非手刻 flag 套件的決策背景,其餘三篇分別是骨架用法、flag 綁定、子指令樹的參考筆記,並附上這 4 篇的標題作為引用清單。
情境二:關鍵字命中不到 title/tags,靠 aliases 觸發圖鄰居擴散 ——問「vault 裡有沒有筆記提到 spf13/cobra 這個套件?」,關鍵字「spf13」在所有筆記的 title/tags 都比對不到,第一階段候選為零。第二階段(擴散)發現「Cobra CLI 框架」這篇筆記的 Frontmatter aliases 正好是 ["spf13/cobra"],命中種子節點,沿 Wikilink 圖擴散一層拿到其餘 3 篇 Cobra 筆記,候選清單變成同一組 4 篇。讀完正文後確認《Cobra CLI 框架》與《選用 Cobra 作為 CLI 框架》兩篇正文都明確提到 github.com/spf13/cobra,回答時明確揭露「標題/標籤未直接命中關鍵字,已沿 Wikilink 圖擴散一層鄰居筆記後才找到候選」。
情境三:完全找不到相關筆記 ——問「vault 裡有沒有討論量子糾纏演算法的筆記?」,關鍵字對所有筆記的 title/tags/label/aliases 都比對不到,擴散無種子可用,直接回報「vault 中找不到相關筆記」,不進入第二階段,也沒有勉強拼湊答案。
demo vault 規模不足以自然湊出「候選過多」的情境(title 關鍵字比對最多也只命中 4 篇),所以另外用一個假設情境驗證限縮規則:若 vault 成長到有 12 篇筆記的 tags 都含 golang,問「vault 裡跟 golang 有關的筆記都在講什麼?」會篩出 12 篇候選,超過可合理逐篇讀取的範圍;此時依「關鍵字命中 title/tags 的次數」排序,只取排序在前的 5 篇進入語意理解,回答裡明確寫出「本次已限縮候選範圍,原本有 12 篇候選,只根據其中 5 篇作答」。這個情境不是真的在 demo vault 上跑出來的,特別標註是假設數據,用來說明規則本身怎麼運作。
/ask-vault 目前的來源揭露只到「列出本次回答實際引用到的筆記標題」,沒有做到逐句可追溯的嚴格引用格式,也沒有防幻覺的強制校驗機制——沒有東西能保證 Agent 統整答案時,真的每一句話都能對應回某篇候選筆記的具體段落。這是刻意的階段切分:今天先把「結構檢索 → 語意理解」這條兩階段流程與候選調整規則定案,Day24 才會在這個基礎上疊加更嚴格的引用格式要求與防幻覺機制,讓 qa-retrieval 從「檢索流程可用」進化到「回答品質可信」。